트랜잭션의 ACID를 실제 주문 처리로 이해하기
트랜잭션의 ACID를 실제 주문 처리로 이해하기
원자성은 전부 성공하거나 전부 실패하게 하고, 일관성은 제약을 지킨다. 격리성은 동시 실행의 간섭을 통제하고 지속성은 커밋 결과를 장애 후에도 보존한다.
목차
- #문제가 되는 상황
- #주문 처리의 불변식부터 정의한다
- #Atomicity: 전부 성공하거나 전부 취소한다
- #Consistency: 올바른 상태 규칙을 지킨다
- #Isolation: 동시 transaction의 간섭을 통제한다
- #Durability: commit 결과를 장애 후에도 보존한다
- #조건부 UPDATE로 잔액과 재고를 보호한다
- #transaction 안에 외부 API를 오래 두지 않는다
- #Outbox로 commit과 이벤트 발행을 연결한다
- #deadlock과 serialization failure는 재시도한다
- #실패와 crash를 테스트하는 방법
- #실전 점검 목록
- #결론
- #관련 노트
문제가 되는 상황
주문 생성에는 재고 차감, 지갑 잔액 차감, 주문 행 생성이 함께 필요할 수 있다. 잔액만 줄고 주문 insert가 실패하거나, 주문은 생성됐지만 재고가 음수가 되면 사용자는 돈을 잃고도 주문을 찾을 수 없는 상태가 된다.
트랜잭션은 여러 SQL을 묶는 문법이 아니라 업무 불변식을 DB의 한 commit 단위로 지키는 장치다. ACID의 네 속성은 각각 다른 실패를 다룬다. 또한 같은 DB 안의 변경에는 강하지만 외부 결제 API와 message broker까지 자동으로 원자화하지는 않는다.
지갑·재고·주문 schema와 금액은 ACID를 설명하기 위한 가상 예제다. 실제 결제나 사용자 데이터를 사용하지 않았다.
주문 처리의 불변식부터 정의한다
ACID를 적용하기 전에 정상 상태를 문장과 제약으로 정의한다.
지갑 잔액은 0 미만이 될 수 없다.
재고 available_quantity는 0 미만이 될 수 없다.
하나의 client_request_id로 주문은 최대 한 건이다.
completed 주문의 item 합계와 주문 total은 일치해야 한다.
CREATE TABLE wallets (
user_id BIGINT PRIMARY KEY,
balance_minor BIGINT NOT NULL,
CHECK (balance_minor >= 0)
);
CREATE TABLE inventory (
product_id BIGINT PRIMARY KEY,
available_quantity INT NOT NULL,
CHECK (available_quantity >= 0)
);
CREATE TABLE orders (
id BINARY(16) PRIMARY KEY,
user_id BIGINT NOT NULL,
client_request_id VARCHAR(80) NOT NULL,
total_minor BIGINT NOT NULL,
status VARCHAR(20) NOT NULL,
UNIQUE KEY uq_orders_request (user_id, client_request_id)
);
트랜잭션은 정의되지 않은 업무 규칙을 알아서 추론하지 않는다. schema constraint와 application command validation이 함께 불변식을 표현해야 한다.
Atomicity: 전부 성공하거나 전부 취소한다
원자성은 transaction 안의 변경이 하나의 단위로 commit되거나 rollback된다는 뜻이다.
START TRANSACTION;
UPDATE wallets
SET balance_minor = balance_minor - 32000
WHERE user_id = 42
AND balance_minor >= 32000;
UPDATE inventory
SET available_quantity = available_quantity - 1
WHERE product_id = 7
AND available_quantity >= 1;
INSERT INTO orders (
id, user_id, client_request_id, total_minor, status
) VALUES (
:order_id, 42, :request_id, 32000, 'confirmed'
);
COMMIT;
중간 SQL이 실패했다고 client library가 항상 자동 rollback하는지 확인해야 한다. 오류를 catch한 뒤 transaction을 계속 사용하거나 실수로 commit하지 않는다.
await database.transaction(async (tx) => {
const walletUpdated = await tx.wallets.debitIfEnough(userId, amount);
if (walletUpdated !== 1) throw new InsufficientBalanceError();
const stockUpdated = await tx.inventory.reserveIfAvailable(productId, 1);
if (stockUpdated !== 1) throw new OutOfStockError();
await tx.orders.insert(order);
});
함수가 throw하면 transaction wrapper가 rollback한다는 계약을 통합 테스트로 확인한다. process가 SQL 두 개 사이에서 죽어도 commit되지 않은 변경이 crash recovery 뒤 되돌아가야 한다.
Consistency: 올바른 상태 규칙을 지킨다
일관성은 transaction 전후에 정의한 불변식과 constraint를 만족하는 상태를 유지한다는 뜻이다. DB가 업무 규칙을 전부 알고 자동으로 올바른 주문을 만들어 준다는 뜻은 아니다.
DB가 직접 보장할 수 있는 예는 다음과 같다.
- primary/unique key로 중복 주문 방지
- foreign key로 존재하는 사용자·상품 참조
- check constraint로 음수 수량 방지
- data type과 NOT NULL로 필수 값 보장
애플리케이션이 책임지는 규칙도 있다.
- 쿠폰이 현재 사용자에게 유효한가?
- 주문 상태가 pending에서 confirmed로 전이 가능한가?
- 가격 snapshot이 승인된 견적과 같은가?
- 통화와 금액 단위가 일치하는가?
function assertTransition(from: OrderStatus, to: OrderStatus) {
const allowed = {
pending: new Set(["confirmed", "cancelled"]),
confirmed: new Set(["shipped", "cancelled"]),
shipped: new Set(["delivered"]),
delivered: new Set(),
cancelled: new Set(),
} satisfies Record<OrderStatus, Set<OrderStatus>>;
if (!allowed[from].has(to)) throw new InvalidOrderTransitionError();
}
DB constraint와 application validation은 경쟁 관계가 아니다. application은 설명 가능한 오류와 복잡한 정책을 담당하고 DB는 모든 쓰기 경로의 마지막 방어선을 담당한다.
Isolation: 동시 transaction의 간섭을 통제한다
두 주문이 동시에 마지막 재고 하나를 읽을 수 있다.
sequenceDiagram
participant A as Transaction A
participant D as Inventory
participant B as Transaction B
A->>D: available = 1 읽기
B->>D: available = 1 읽기
A->>D: 1 차감
B->>D: 1 차감
Note over D: 단순 read-then-write면 lost update 또는 음수 위험격리성은 동시 transaction이 어떤 중간 상태를 보고 어떤 충돌을 허용하는지 통제한다. “transaction을 사용했다”는 사실만으로 모든 race가 사라지지 않는다. 격리 수준, locking read, 조건부 update, unique constraint를 업무에 맞게 조합한다.
UPDATE inventory
SET available_quantity = available_quantity - 1
WHERE product_id = :product_id
AND available_quantity >= 1;
영향받은 행이 1이면 확보 성공, 0이면 품절이다. 읽고 쓸 필요를 한 원자 statement로 줄였다.
격리 수준과 읽기 현상은 격리 수준과 Dirty Read Non-repeatable Read Phantom Read에서 자세히 다룬다.
Durability: commit 결과를 장애 후에도 보존한다
지속성은 DB가 commit 성공을 client에 알린 결과가 process나 machine 장애 후 recovery에서도 남도록 하는 속성이다. 일반적으로 redo log, WAL, fsync와 복제 설정이 관계한다.
다만 지속성은 배포 구조와 설정의 영향을 받는다.
- storage가 실제로 durable write를 보장하는가?
- DB가 commit마다 log flush를 기다리는가?
- primary 장애 조치 시 어떤 replication 수준을 보장하는가?
- backup과 point-in-time recovery가 준비되어 있는가?
- commit 응답 직후 datacenter 전체가 사라지는 상황의 RPO는 무엇인가?
DB 기본 설정을 바꿔 latency를 줄이면 장애 시 최근 commit 일부를 잃을 수 있다. 성능 옵션을 “더 빠른 설정”으로만 보지 말고 RPO/RTO 계약과 함께 결정한다.
지속성은 backup을 대신하지 않는다. 정상 commit된 잘못된 DELETE도 durable하게 보존되므로 backup·audit·복구 훈련이 필요하다.
조건부 UPDATE로 잔액과 재고를 보호한다
read-modify-write를 application 메모리에서 수행하면 동시 update를 잃을 수 있다.
// 좋지 않은 예
const wallet = await walletRepository.find(userId);
if (wallet.balanceMinor < amount) throw new InsufficientBalanceError();
wallet.balanceMinor -= amount;
await walletRepository.save(wallet);
조건과 변경을 한 statement에 둔다.
UPDATE wallets
SET balance_minor = balance_minor - :amount
WHERE user_id = :user_id
AND balance_minor >= :amount;
const affectedRows = await tx.wallets.debitIfEnough(userId, amount);
if (affectedRows !== 1) {
throw new InsufficientBalanceError();
}
optimistic locking version을 사용할 수도 있다.
UPDATE orders
SET status = :next_status,
version = version + 1
WHERE id = :order_id
AND version = :expected_version;
0행이면 누군가 먼저 상태를 바꾼 것이므로 최신 상태를 읽어 conflict를 처리한다.
transaction 안에 외부 API를 오래 두지 않는다
DB transaction 안에서 결제사 HTTP 응답을 기다리면 row lock과 connection을 네트워크 시간만큼 보유한다.
// 피하고 싶은 구조
await database.transaction(async (tx) => {
await tx.orders.lock(orderId);
const payment = await externalPaymentApi.charge(command); // 긴 네트워크 대기
await tx.orders.markPaid(orderId, payment.id);
});
DB rollback은 외부에서 이미 성공한 결제를 취소하지 않는다. 단일 ACID transaction으로 묶을 수 없는 작업은 상태 머신과 보상·재조정 workflow로 설계한다.
pending → payment-requested → paid
↘ payment-unknown → reconciliation
↘ failed
외부 결제 요청에 idempotency key를 보내고 응답 timeout 시 결제 상태 조회 API로 확인한다. lock을 잡는 짧은 DB transaction과 외부 작업을 분리한다.
Outbox로 commit과 이벤트 발행을 연결한다
주문 commit 후 message publish 전에 process가 죽으면 이벤트가 사라진다.
DB commit 성공
→ process crash
→ broker publish 미실행
주문과 outbox event를 같은 DB transaction에 기록한다.
START TRANSACTION;
INSERT INTO orders (...) VALUES (...);
INSERT INTO outbox_events (
id, event_type, aggregate_id, payload, created_at
) VALUES (
:event_id, 'order-created', :order_id, :payload, CURRENT_TIMESTAMP
);
COMMIT;
별도 relay가 미발행 event를 broker로 보내고 성공 상태를 기록한다. relay가 publish 후 상태 기록 전에 죽으면 중복 전달될 수 있으므로 consumer는 event ID를 기준으로 멱등 처리한다.
flowchart LR
T[Order + Outbox Transaction] --> D[(Database)]
D --> R[Outbox Relay]
R --> B[Message Broker]
B --> C[Idempotent Consumer]Outbox는 DB와 broker의 원자적 commit을 흉내 내는 대신 at-least-once 전달과 복구 가능한 상태를 선택한다.
deadlock과 serialization failure는 재시도한다
정상적인 동시 transaction도 lock 순서가 엇갈리면 deadlock이 생길 수 있다. DB는 한 transaction을 희생자로 골라 rollback한다. Serializable 격리에서는 serialization failure가 예상된 충돌 신호일 수 있다.
async function runOrderTransaction(command: CreateOrderCommand) {
return retryTransaction(
() => database.transaction((tx) => createOrder(tx, command)),
{
maxAttempts: 3,
retryable: isDeadlockOrSerializationFailure,
backoff: exponentialBackoffWithJitter,
},
);
}
transaction 전체를 처음부터 재실행하고 외부 부수 효과는 transaction 밖 또는 멱등하게 처리한다. validation 400, unique 업무 충돌, 잔액 부족을 deadlock처럼 무조건 재시도하지 않는다.
lock 순서를 통일하고 transaction을 짧게 유지하면 충돌을 줄일 수 있다. 예를 들어 여러 inventory row를 잠글 때 product ID 오름차순으로 접근한다.
실패와 crash를 테스트하는 방법
성공 경로 test만으로 ACID 경계를 검증할 수 없다.
1. 지갑 차감 후 주문 insert 오류 주입 → 둘 다 rollback인가?
2. 마지막 재고에 동시 주문 50개 → 정확히 한 건만 성공하는가?
3. commit 직후 응답 유실 → 같은 request ID 재시도 시 중복 없는가?
4. outbox publish 전 process 종료 → 재시작 후 event가 발행되는가?
5. 두 transaction의 lock 순서를 교차 → deadlock retry가 안전한가?
6. primary 장애 조치 직후 commit 데이터의 RPO가 계약과 맞는가?
통합 테스트는 실제 transaction DB와 constraint를 사용한다. mock repository는 rollback, lock, unique race를 재현하지 못한다.
관측 지표에는 transaction latency, rollback·deadlock 수, lock wait, connection pool 대기, outbox lag, reconciliation backlog를 포함한다.
실전 점검 목록
- transaction 전후에 지켜야 할 업무 불변식을 정의했는가?
- 영향 행 수와 constraint 실패를 확인하는가?
- 모든 오류 경로에서 rollback되고 connection이 반환되는가?
- 외부 API를 열린 DB transaction 안에서 기다리지 않는가?
- DB commit과 event 발행 사이에 outbox가 있는가?
- deadlock·serialization failure만 제한적으로 재시도하는가?
- commit 응답 유실에 idempotency key가 있는가?
- crash·동시성·장애 조치 테스트로 실제 보장을 확인했는가?
원자성은 전부 성공하거나 전부 실패하게 하고, 일관성은 제약을 지킨다. 격리성은 동시 실행의 간섭을 통제하고 지속성은 커밋 결과를 장애 후에도 보존한다.
결론
ACID는 주문 처리의 여러 변경을 신뢰 가능한 commit 단위로 만드는 네 가지 서로 다른 보장이다. 원자성은 전부 commit/rollback하고, 일관성은 정의한 불변식과 제약을 유지하며, 격리성은 동시 간섭을 통제하고, 지속성은 commit 결과를 장애 후에도 보존한다. 외부 API와 broker는 이 경계 밖이므로 idempotency·상태 머신·outbox·reconciliation을 함께 설계해야 한다.